Spring Boot – Lombok

July 19, 20265 min readUpdated 8/18/2026

Java's boilerplate is real: a class with eight fields needs eight getters, eight setters, a constructor, equals, hashCode and toString, none of which carry information. Lombok generates them from annotations at compile time.

It is also the library most likely to cause a subtle bug in a Spring Boot application, and this lesson spends as much time on that as on the convenience.

Setup

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

<optional>true</optional> keeps Lombok off your consumers' classpath — it is a compile-time tool and nothing needs it at runtime. The Boot plugin also excludes it from the executable jar:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <excludes>
            <exclude>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </exclude>
        </excludes>
    </configuration>
</plugin>

If you also use MapStruct, the annotation-processor order is load-bearing — see lesson 19.

The annotations worth using

@Slf4j — the one with no downside

@Slf4j
@Service
public class ProductServiceImpl implements ProductService {

    public ProductDTO createProduct(ProductCreateDTO dto) {
        log.info("Creating product {}", dto.name());
    }
}

It replaces private static final Logger log = LoggerFactory.getLogger(ProductServiceImpl.class); — a line whose only failure mode is naming the wrong class after a copy-paste. 28 files in the pizza API use it.

Note the {} placeholder rather than string concatenation: the arguments are only formatted if the level is enabled, so a disabled log.debug costs nothing.

@RequiredArgsConstructor — the one that fixes injection

@Service
@RequiredArgsConstructor
public class MailServiceImpl implements MailService {

    private final ObjectProvider<JavaMailSender> mailSenderProvider;
    private final TemplateEngine templateEngine;
    private final PizzaProperties properties;
}

It generates a constructor taking every final field. Spring uses it automatically, so you get constructor injection with less code than field injection — which removes the only real argument people had for @Autowired on fields (lesson 6).

@Builder — for objects with many fields

return userDAO.save(User.builder()
        .email(email)
        .fullName(name == null || name.isBlank() ? email : name)
        .passwordHash(null)
        .role(UserRole.CUSTOMER)
        .build());

Readable, and immune to the classic bug where two adjacent String constructor parameters get swapped and everything still compiles.

⚠️ @Builder ignores field initialisers unless you add @Builder.Default. Without it, a field declared = true comes out of the builder as false:

@Builder.Default
@Column(name = "active", nullable = false)
private boolean active = true;

@Builder.Default
@Column(name = "deleted", nullable = false)
private boolean deleted = false;

Lombok warns about this. Do not ignore the warning — a product silently created inactive is not obvious from the calling code.

@Getter and @Setter

Per field or per class. Prefer them over @Data on entities, for the reason below.

⚠️ @Data on a JPA entity

@Data bundles @Getter, @Setter, @ToString, @EqualsAndHashCode and @RequiredArgsConstructor. On a plain DTO it is fine. On a JPA entity it causes two genuinely nasty problems, and the pizza API's Product documents them:

/**
 * <p>Excluded from equals/hashCode/toString. {@code @Data} would otherwise walk the collection
 * — forcing a lazy load on every toString, and recursing forever through the child's parent
 * reference. This is the single most common way @Data and JPA go wrong together.
 */
@ToString.Exclude
@EqualsAndHashCode.Exclude
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true,
           fetch = FetchType.LAZY)
private List<ProductSize> sizes = new ArrayList<>();

Problem one: collections in toString

A generated toString includes every field, including lazy collections. So a debug log line triggers a database query — or, if Product prints its sizes and each ProductSize prints its product, a StackOverflowError. A logging statement that crashes the application is a memorable afternoon.

Problem two: equals and hashCode

Generated equals and hashCode use all fields, including the generated id. Which breaks in a way JPA makes easy to hit:

Product product = new Product();       // id is null
Set<Product> set = new HashSet<>();
set.add(product);                       // hashed with id == null

productRepository.save(product);        // JPA assigns id = 42 — the hash changes

set.contains(product);                  // false. It is in the set and cannot be found.

The object's hash changed while it was in a hash-based collection. The safe pattern is to exclude collections and base equality on the business key — here, publicId, which is assigned in @PrePersist and never changes.

@ToString and secrets

User has a passwordHash. A generated toString includes it, and one log.debug("Loaded {}", user) puts a password hash in your logs — where it is retained, shipped to a log aggregator, and visible to everyone with access to it.

@ToString.Exclude
private String passwordHash;

The same applies to tokens, API keys and card details. The pizza API's ApiSubError takes the same care in prose: "The rejected value. Never populated for passwords or other secrets."

Records instead, where you can

Java records give you immutability, equals, hashCode, toString and a constructor with no library at all:

public record ProductDTO(
        UUID id,
        String name,
        String description,
        ProductType type,
        List<ProductSizeDTO> sizes) {}

The pizza API uses records for every DTO and Lombok for entities. That split is principled rather than arbitrary: DTOs are immutable value objects, which is exactly what a record is; JPA entities need a no-arg constructor and mutable fields, which is exactly what a record is not.

The honest downsides

  • Your IDE needs a plugin. Without it, code referencing generated methods is a sea of red.
  • Debugging steps into generated code you cannot see in the file.
  • It is a compile-time hack. Lombok modifies the AST through internal compiler APIs, which is why each new JDK occasionally needs a new Lombok.
  • It hides cost. @Data on an entity looks like tidiness and is a behavioural change.

Those are real, and the boilerplate it removes is real too. The pizza API's position — records for DTOs, targeted Lombok annotations on entities, never bare @Data — gets most of the benefit and avoids the traps.

What to take from this

  • @Slf4j and @RequiredArgsConstructor everywhere. Both are pure win.
  • @Builder.Default or your field initialisers are ignored.
  • Never @Data on a JPA entity. Use targeted annotations and exclude collections from toString and equals.
  • @ToString.Exclude on every secret.
  • Records for DTOs — no library needed.

Next: caching — and the eviction that has to be wired to every write path.