跪拜 Guibai
← Back to the summary

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


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


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


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:

What You Need to Know


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


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):

What You Need to Know


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:


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

  1. Discern scenarios for JPA Entity with @Data — Use @Getter + @Setter separately, @ToString.Exclude to break bidirectional recursion
  2. @Builder + @SuperBuilder are the biggest productivity boosters in daily coding — Use Builder directly for classes with ≥4 fields, @SuperBuilder for inheritance scenarios
  3. @SneakyThrows, @Cleanup, @UtilityClass, @With, @Singular are 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., WithBuilderDemoUser, AfterStringUtils), 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