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.
@Dataon 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
@Slf4jand@RequiredArgsConstructoreverywhere. Both are pure win.@Builder.Defaultor your field initialisers are ignored.- Never
@Dataon a JPA entity. Use targeted annotations and exclude collections fromtoStringandequals. @ToString.Excludeon every secret.- Records for DTOs — no library needed.
Next: caching — and the eviction that has to be wired to every write path.