Lombok is often discussed as a way to write less Java. We count the getters, constructors, and builder methods that disappear from the source file. Then we call the result “cleaner code.”

But the number of lines is not the most interesting part.

When I add a Lombok annotation, I also choose how an object is created, how it changes, and what makes it equal to another object. These are design decisions. Lombok only makes them less visible.

This is why I do not see @Builder, @Value, or @RequiredArgsConstructor as simple shortcuts. They are small tools that can support useful design patterns. They can also hide a weak design behind a pleasant API.

This article is not a list of Lombok features. It is a look at the patterns they create and the questions I ask before using them.


Required constructors make dependencies explicit

Consider a typical Spring service:

@Service
@RequiredArgsConstructor
public class InvoiceService {

    private final InvoiceRepository repository;
    private final TaxCalculator taxCalculator;
    private final Clock clock;
}

@RequiredArgsConstructor generates a constructor for the final fields. This removes a few lines, but the design value is more important: the service cannot exist without its dependencies.

This supports constructor injection and makes the class state clear. There are no optional setters and no partly initialized service. A test must also provide every dependency when it creates the object.

The annotation works well here because the pattern already makes sense. Lombok does not create the design. The final fields and the constructor rule express the design, and Lombok writes the repetitive part.

This is also why I avoid @Data on services. @Data would generate setters, equality methods, and a toString() method that a service does not need. It would make the public API larger without improving the design.

A useful rule is to choose the smallest Lombok annotation that matches the role of the class. A service may need a constructor. It does not automatically need the complete JavaBean pattern.


staticName creates a factory, but not a domain language

Constructor annotations can generate a static factory method:

@Getter
@RequiredArgsConstructor(staticName = "of")
public class PageSlice<T> {
    private final List<T> items;
    private final boolean hasNext;
}

The generated constructor is private, and callers use:

PageSlice<Order> page = PageSlice.of(orders, true);

This follows the static factory method pattern. It can make generic type inference easier and gives object creation a clear entry point.

However, of is still a general name. It does not explain why an object is valid. A method such as Money.euros(20) or RetryPolicy.withMaxAttempts(3) tells a stronger domain story and can validate its input.

Lombok's staticName is useful when object creation is simple. When creation has business rules, I prefer to write the factory method. Saving five lines is not worth losing a meaningful name or a good place for validation.

The pattern matters more than the generated code.


A builder is a construction API, not a validation strategy

@Builder is one of Lombok's most attractive annotations:

SearchRequest request = SearchRequest.builder()
        .query("spring lombok")
        .locale("en")
        .limit(20)
        .build();

The code is readable, especially when a class has several optional values. It avoids a long constructor where two parameters can easily be exchanged.

But a generated builder accepts an incomplete state until build() is called. By default, missing object values become null, numeric values become 0, and booleans become false. A nice fluent API does not prove that the final object is valid.

For a domain object, I often place @Builder on a constructor instead of the whole class:

@Value
public class SearchRequest {
    String query;
    String locale;
    int limit;

    @Builder
    private SearchRequest(String query, String locale, int limit) {
        if (query == null || query.isBlank()) {
            throw new IllegalArgumentException("query is required");
        }
        if (limit < 1 || limit > 100) {
            throw new IllegalArgumentException("limit must be between 1 and 100");
        }

        this.query = query;
        this.locale = locale;
        this.limit = limit;
    }
}

The builder remains convenient, but every call to build() goes through the constructor and its rules. The class cannot be created in an invalid state through that path.

The location of @Builder is therefore a design choice. On a class, it creates a builder for all fields and behaves like a package-private all-arguments constructor. On a constructor or method, it creates a builder for a specific creation path.

@Builder.Default also needs attention. A field initializer is not automatically used by a class-level builder unless the field has @Builder.Default. This is a small detail, but it can change production behavior when a default timeout, retry count, or feature flag becomes 0 or false.


@Value and @With support immutable updates

@Value makes a class final by default, makes its fields private and final, and generates getters, a constructor, equals(), hashCode(), and toString().

Combined with @With, it supports a simple immutable update pattern:

@Value
@With
public class DeploymentPlan {
    String region;
    List<String> services;
}
DeploymentPlan parisPlan = originalPlan.withRegion("eu-west-3");

withRegion() returns a new DeploymentPlan. The original object does not change. This is useful for value objects, configuration snapshots, and messages that move between application layers.

There is an important limit: this is shallow immutability. The reference to services is final, but the list can still be mutable. Both the old and new objects may point to the same list.

If the class must be truly immutable, it should protect its collections:

this.services = List.copyOf(services);

Lombok can generate the outer structure of an immutable object. It cannot decide whether the objects inside it are safe to share.

Another detail is that @With needs an all-arguments constructor with fields in the expected order. A custom constructor can improve validation, but it must still match the structure required by the generated with methods.


Generated equality defines identity

equals() and hashCode() are often treated as boilerplate. In many domain models, they answer an important question: what makes two objects the same?

By default, @EqualsAndHashCode uses all non-static and non-transient fields. This is convenient, but it also means that adding a new field can change equality without changing any visible method.

That can affect sets, map keys, caches, and tests.

When identity must stay stable, I prefer to make it explicit:

@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class CustomerReference {

    @EqualsAndHashCode.Include
    private final UUID customerId;

    private final String displayName;
}

Changing displayName does not change the identity of the reference. A reader can see this decision without opening generated code.

This is another reason to use @Data carefully. It silently includes @EqualsAndHashCode with default behavior. That may be correct for a small data transfer object, but it is not a safe default for every entity or domain object.

Equality is part of the model. It deserves an explicit decision, even when Lombok writes the method.


@SuperBuilder can make inheritance look easier than it is

@SuperBuilder creates builders that include fields from parent classes. It solves a real technical problem, but it also makes class inheritance very easy to extend.

The annotation is experimental. It requires every superclass in the hierarchy to use @SuperBuilder, and it is not compatible with @Builder. The generated code also uses complex generic types.

These limits are not only technical details. They show that the builder is now connected to the complete inheritance tree. A change in a parent class can change builders in every child class.

I use @SuperBuilder only when the inheritance model already represents the domain well. I do not use it as a reason to create a hierarchy. In many cases, composition keeps object creation simpler and reduces the number of fields exposed by one large builder.

Lombok can reduce the cost of a pattern, but a lower cost does not always make the pattern a better choice.


Team rules belong in lombok.config

Different classes need different patterns, but a team can still define limits. For example:

config.stopBubbling = true

# Ask for an explicit decision before using broad or experimental patterns
lombok.data.flagUsage = warning
lombok.superBuilder.flagUsage = warning

These warnings do not ban the annotations. They make the decision visible during compilation and code review.

This is a useful role for lombok.config. It can describe which shortcuts are normal in a codebase and which ones require more thought. The file becomes part of the architecture rules, not only a collection of formatting preferences.


The question I ask before adding an annotation

Before using Lombok, I now ask a simple question:

If I wrote the generated code by hand, would I still choose this public API?

If the answer is yes, Lombok is probably removing useful repetition. If the answer is no, the annotation may be hiding a design problem.

@RequiredArgsConstructor can support constructor injection. staticName can create a simple factory. @Builder can provide a clear construction API. @Value and @With can support immutable objects. @EqualsAndHashCode can express value identity.

None of these patterns becomes correct only because an annotation generates it.

The best use of Lombok is not to generate the most code. It is to keep good design visible while letting the compiler handle the repetitive parts.

References