Skip to content
All posts

Java Records vs Lombok: Do You Still Need Lombok?

By 8 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:

Product.java (Lombok)
@Value
public class Product {
    String code;
    String name;
    BigDecimal price;
}

The same thing as a record, with no library involved:

Product.java (record)
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:

Money.java
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 @ConfigurationProperties records 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.
PaymentProperties.java
@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:

PaymentResult.java
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:

OrderEntity.java
@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 @Builder works on records, and so does @With for copies with one field changed.
  • Entities and other mutable classes. @Getter, @Setter and @NoArgsConstructor remove real boilerplate where records can’t be used.
  • Services. @RequiredArgsConstructor for constructor injection and @Slf4j for a logger are unrelated to records and remain convenient.
SearchRequest.java
@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();
Decision chart: JPA entities stay classes with Lombok getters and setters; immutable data such as DTOs, events, commands and configuration become records, optionally with Lombok @Builder; other mutable types use Lombok or plain Java
Choosing between a record and Lombok, one type at a time.

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:

  1. Start with request and response DTOs and events: they are already immutable and rarely have custom behaviour.
  2. Replace getter calls. getPrice() becomes price(), and your IDE’s “convert to record” refactoring updates the call sites for you.
  3. Watch for code that needs JavaBean getters: some reflection-based tools, mappers and templates look for getX() methods. Spring Batch’s beanMapped() writer is one example.
  4. Keep @Builder where call sites use it, by adding it to the record.
  5. 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, @RequiredArgsConstructor and @Slf4j.