Java Records vs Lombok: Do You Still Need Lombok?
By Uddip Acharjee Shuvo8 min read
For years, Lombok was how Java developers avoided writing getters, constructors and equals by hand, and all of my recent Spring repositories use it. Since Java 16, the language has its own answer for one big part of that job: records. On Java 21, with record patterns and pattern matching for switch on top, the question is fair: what is Lombok still for?
Short answer: records should replace Lombok for immutable data, Lombok still earns its place in a few spots, and JPA entities can’t be records at all.
The same value type, three ways
A product as seen by other modules, first with Lombok’s @Value:
@Value
public class Product {
String code;
String name;
BigDecimal price;
}The same thing as a record, with no library involved:
public record Product(String code, String name, BigDecimal price) {}Both give you private final fields, a constructor for all of them, accessors, equals, hashCode and toString. The visible difference is accessor naming: a record has product.price(), while Lombok generates product.getPrice(). The bigger difference is that the record is plain Java. There is no annotation processor, no IDE plugin, and no waiting for a Lombok release that supports the next JDK.
What records do well
Validation in a compact constructor
A compact constructor runs before the fields are assigned, so it can reject or normalize values. Lombok’s @Value has no equivalent short of writing the constructor yourself:
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(currency, "currency");
if (amount.signum() < 0) {
throw new IllegalArgumentException("amount must not be negative");
}
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
}
public Money plus(Money other) {
return new Money(amount.add(other.amount), currency);
}
}The Spring stack already speaks records
- Request and response bodies. Jackson has supported records since 2.12, and Bean Validation annotations work on record components.
- Configuration properties. Spring Boot binds
@ConfigurationPropertiesrecords through their constructor. - Query results. Spring Data projections and JPQL constructor expressions can return records.
- Events. An event is a fact that already happened, which makes it a natural record.
@ConfigurationProperties("payment")
public record PaymentProperties(URI baseUrl, Duration timeout, int maxRetries) {}Records and pattern matching
Java 21 can take records apart in a switch. Together with a sealed interface, the compiler checks that every case is handled, with no default branch needed:
public sealed interface PaymentResult
permits Approved, Declined, NeedsReview {}
public record Approved(String transactionId) implements PaymentResult {}
public record Declined(String reason) implements PaymentResult {}
public record NeedsReview(String caseId, Duration sla) implements PaymentResult {}
String describe(PaymentResult result) {
return switch (result) {
case Approved(var id) -> "Paid, transaction " + id;
case Declined(var reason) -> "Declined: " + reason;
case NeedsReview(var id, var sla) -> "In review (" + id + "), answer within " + sla.toHours() + "h";
};
}Add a fourth result type and every switch that doesn’t handle it stops compiling. Lombok can’t offer that, because it is a feature of the language, not of generated code.
Where records can’t go: JPA entities
An entity must have a no-argument constructor, must not be final so Hibernate can proxy it, and changes state over its lifetime. A record is final, immutable and has only its canonical constructor, so it breaks all three rules. Entities stay classes:
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // for JPA only
public class OrderEntity {
@Id @GeneratedValue
private Long id;
private String orderNumber;
@Enumerated(EnumType.STRING)
private OrderStatus status;
}Since Hibernate 6.2, a record can be an @Embeddable, which suits value objects inside an entity, such as an address or a Money amount.
Where Lombok still earns its keep
- Builders. A record with many optional fields is awkward to construct positionally. Lombok’s
@Builderworks on records, and so does@Withfor copies with one field changed. - Entities and other mutable classes.
@Getter,@Setterand@NoArgsConstructorremove real boilerplate where records can’t be used. - Services.
@RequiredArgsConstructorfor constructor injection and@Slf4jfor a logger are unrelated to records and remain convenient.
@Builder
public record SearchRequest(String keyword, Category category,
BigDecimal minPrice, BigDecimal maxPrice,
int page, int size) {}
var request = SearchRequest.builder()
.keyword("java")
.size(20)
.build();Migrating from @Value to records
You don’t have to convert a codebase in one go. A type at a time works, in this order:
- Start with request and response DTOs and events: they are already immutable and rarely have custom behaviour.
- Replace getter calls.
getPrice()becomesprice(), and your IDE’s “convert to record” refactoring updates the call sites for you. - Watch for code that needs JavaBean getters: some reflection-based tools, mappers and templates look for
getX()methods. Spring Batch’sbeanMapped()writer is one example. - Keep
@Builderwhere call sites use it, by adding it to the record. - Leave entities alone.
Checklist
- Records for DTOs, events, commands, configuration and value objects.
- Compact constructors for validation and normalization.
- Sealed interfaces with records where a type has a fixed set of shapes.
- Entities as classes with
@Getter/@Setter, never@Data; records for embeddables on Hibernate 6.2+. - Lombok kept for builders, mutable classes,
@RequiredArgsConstructorand@Slf4j.