Lombok in a Spring Boot Codebase, and Which Annotations to Ban

3 min read

  • Java
  • Spring Boot
  • JPA

Lombok removes boilerplate, and most of the boilerplate it removes was genuinely worthless. The problem is that a few of its annotations generate code with semantics you would never have written by hand, and they generate it silently, in a file you cannot see.

@Data on an entity is the big one

@Data bundles getters, setters, toString, equals, hashCode, and a required-args constructor. On a JPA entity, three of those are actively harmful.

equals and hashCode. Lombok generates them over all fields. For an entity, that means the hash code changes the moment any field is set, and an entity that was put into a HashSet before it was persisted can never be found again. It also means equals touches every field, including lazy associations, which triggers loading. This is precisely the situation that makes the equals and hashCode contract worth understanding rather than delegating.

For entities, use the business key if there is one, or the id with a constant hash code:

@Entity
@Getter
@Setter
public class Order {

    @Id @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order other)) return false;
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

A constant hash code degrades a hash set to a list, which is fine for the handful of entities in one transaction and is correct across the whole lifecycle.

toString. The generated one prints every field, including lazy collections. Logging an entity then fires queries, or throws LazyInitializationException outside the session, and on a bidirectional association two toString calls recurse until the stack overflows. Exclude associations or write it yourself.

Setters on everything. A public setter for every column removes any chance of the entity enforcing its own invariants. Model state changes as methods that mean something: cancel(), not setStatus().

The short rule: @Getter and @Setter on entities, nothing else from the @Data bundle.

@Builder and required fields

A builder makes every field optional at the call site. A four-argument constructor will not compile if you forget one; a builder compiles and fails at runtime, or worse, persists a row with a null where a value was meant to be.

Order.builder()
    .customerId(id)
    .total(total)
    .build(); // status never set, no compiler complaint

If you use @Builder, pair it with validation or explicit checks in a private constructor, and prefer a plain constructor when there are fewer than about five fields.

@Builder.Default is also a known foot-gun: a field initialiser is ignored by the generated builder unless you add it, and Lombok only warns in some versions.

@AllArgsConstructor and injection

@Service
@AllArgsConstructor
public class OrderService {
    private final OrderRepository orders;
    private final PaymentClient payments;
}

This is fine, and it is genuinely the nicest way to do constructor injection. The failure mode is subtle: the constructor's parameter order follows field declaration order, so reordering two fields of the same type silently swaps two dependencies at every call site that used the constructor directly, usually a test. Prefer @RequiredArgsConstructor, which at least only covers final fields.

@SneakyThrows

It throws a checked exception without declaring it. The compiler cannot see it, the caller cannot catch it by type without a cast, and the stack trace tells a confusing story. It is occasionally justifiable for an exception that genuinely cannot happen, like UTF-8 being unsupported. Using it to avoid thinking about an IOException is how a failure ends up swallowed three layers up by a generic handler, and the checked versus unchecked decision deserves a real answer instead.

@Value versus records

@Value makes an immutable class with all the accessors. Since Java 16, that is what records do natively, with pattern matching support and no build plugin. For new DTOs, use a record.

A reasonable policy

Allow @Getter, @Setter, @RequiredArgsConstructor, and @Slf4j. Be careful with @Builder. Ban @Data on entities and @SneakyThrows outright. Reach for a record before @Value. That keeps the boilerplate savings and removes the annotations that generate semantics nobody chose.