Java Records vs Lombok @Data: One-Line Replacement Guide

This article compares Java 16 records with Lombok's @Data annotation, detailing generated code differences, a feature comparison table, a Spring Boot migration example, scenarios where records cannot replace @Data, and key caveats including immutability constraints, Jackson deserialization requirements, and mutable collection pitfalls.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Java Records vs Lombok @Data: One-Line Replacement Guide

Introduction

A typical request parameter class using Lombok's @Data looks like:

@Data
public class CreateOrderReq {
    private String sku;
    private int qty;
    private String couponCode;
}

After compilation, this class gains getters, setters, equals, hashCode, toString, and an all-args constructor — code you didn't write but exists in bytecode. The project also acquires a compile-time dependency on Lombok, and IDEs need the Lombok plugin.

With Java 16 or later, the same class can be reduced to a single line:

public record CreateOrderReq(String sku, int qty, String couponCode) {}

This article does not argue that Lombok is bad; it solves a real problem well. The point is that record, as a built-in language feature, can directly replace @Data in many places while removing the build-time dependency.

1. What @Data Actually Generates

@Data

bundles several Lombok annotations: @Getter, @Setter, @RequiredArgsConstructor, @ToString, @EqualsAndHashCode.

For the CreateOrderReq example, the compiler produces roughly:

public class CreateOrderReq {
    private String sku;
    private int qty;
    private String couponCode;

    public CreateOrderReq(String sku, int qty, String couponCode) {
        this.sku = sku; this.qty = qty; this.couponCode = couponCode;
    }

    public String getSku() { return this.sku; }
    public void setSku(String sku) { this.sku = sku; }
    public int getQty() { return this.qty; }
    public void setQty(int qty) { this.qty = qty; }
    public String getCouponCode() { return this.couponCode; }
    public void setCouponCode(String couponCode) { this.couponCode = couponCode; }

    public boolean equals(Object o) { /* compares three fields, no super.equals */ }
    public int hashCode() { /* derived from three fields */ }
    public String toString() { return "CreateOrderReq(sku=" + ... + ")"; }
}

Two important facts:

Fields are mutable by default. Lombok generates a setter for every non- final field, so @Data objects are inherently mutable. equals / hashCode default to not calling the superclass . With @EqualsAndHashCode(callSuper = false) (the default), parent fields are ignored — a common source of bugs in inheritance hierarchies.

Lombok is a compile-time annotation processor; it inserts code before javac emits bytecode. No runtime dependency, but the build must find Lombok and IDEs need the plugin.

2. What a Record Generates in One Line

record

became standard in Java 16 (preview in 14/15). The components in the parentheses are automatically turned into:

A private final field for each component.

A canonical constructor with parameters matching the component order.

An accessor method for each component, named exactly like the component (no get prefix). equals, hashCode, toString based on all components.

The generated code corresponds almost one-to-one with the @Data version:

public record CreateOrderReq(String sku, int qty, String couponCode) {
    // compiler generates: private final String sku; (same for others)
    // compiler generates: public CreateOrderReq(String sku, int qty, String couponCode) { ... }
    // compiler generates: public String sku() { return this.sku; } (no get prefix)
    // compiler generates: equals / hashCode / toString based on all three components
}

Key constraints of records:

Component fields are final; the record itself is final — cannot be extended and cannot extend another class (implicitly extends java.lang.Record).

No instance fields beyond the components. Extra state (e.g., creation timestamp) must be a component, a static field, or a derived method.

Accessors are named sku() not getSku() — this can break existing JSON contracts.

Records also offer a compact constructor for validation without manual field assignment:

public record CreateOrderReq(String sku, int qty, String couponCode) {
    public CreateOrderReq {
        if (sku == null || sku.isBlank()) {
            throw new IllegalArgumentException("sku 不能为空");
        }
        if (qty <= 0) {
            throw new IllegalArgumentException("qty 必须为正");
        }
    }
}

Validation lives in the constructor; once constructed the object is guaranteed valid — no setters to corrupt state.

3. Record vs @Data Comparison

The following dimensions compare the two approaches:

Mutability : @Data — mutable (has setters); record — immutable ( final fields, no setters).

Accessor naming : @Data — getSku(); record — sku().

equals/hashCode : @Data — based on all fields, default callSuper=false; record — based on all components.

Inheritance (extend other class) : @Data — yes; record — no (implicitly extends Record).

Inheritance (be extended) : @Data — yes; record — no ( final).

Validation : @Data — via @NonNull or manual setters; record — compact constructor.

Builder : @Data — @Builder one-liner; record — must write static factory manually.

Serialization : both require manual Serializable implementation.

Jackson deserialization : @Data — needs Lombok plugin; record — native support in Jackson 2.12+.

External dependency : @Data — compile-time Lombok dependency; record — JDK built-in, zero dependency.

Records win on immutability and zero dependencies — immutable objects can be safely shared in concurrency and used as HashMap keys; zero dependency means no plugin setup for new developers or CI. @Data wins when mutability is required (e.g., JPA entities, multi-step form objects) and for the convenient @Builder pattern with many optional parameters.

The accessor naming difference ( sku() vs getSku()) is subtle but critical: Jackson infers JSON field names from getter names. Older Jackson versions or configurations may map sku() to "sku" or "getSku" depending on version/settings. Existing frontend contracts expecting getSku -style names may break if switched to records.

4. Code Example: Migrating a Request DTO from @Data to Record

Converting a Spring Boot request class is nearly painless with a recent version.

Lombok version

@Data
public class CreateOrderReq {
    @NotBlank
    private String sku;
    @Min(1)
    private int qty;
    private String couponCode;
}

Record version

public record CreateOrderReq(
    String sku,
    int qty,
    String couponCode) {

    public CreateOrderReq {
        if (sku == null || sku.isBlank()) {
            throw new IllegalArgumentException("sku 不能为空");
        }
        if (qty < 1) {
            throw new IllegalArgumentException("qty 必须 >= 1");
        }
    }
}

Controller usage (unchanged)

@PostMapping("/orders")
public OrderDTO create(@Valid @RequestBody CreateOrderReq req) {
    // req.sku() accesses value; req is immutable, no worry about modification
    return orderService.create(req.sku(), req.qty(), req.couponCode());
}

Supporting details

Bean Validation : Annotations like @NotBlank / @Min can be placed directly on record components; Spring validates after deserialization. The compact constructor provides a second layer of defense — use one or the other, not both.

Jackson deserialization : Spring Boot 2.5+ includes Jackson 2.12+, which natively uses the canonical constructor. For older Jackson (2.11 or earlier), parameter names may be lost at compile time; you must annotate the constructor with @JsonCreator and parameters with @JsonProperty.

MapStruct / manual mapping : Works fine. MapStruct calls the canonical constructor for target records and recognizes sku() accessors for source records.

After migration, the class can never be corrupted by setQty(-1) because setters don't exist. Validation logic is centralized in the constructor, eliminating the "valid at construction, invalid after setter" intermediate state.

5. When Record Cannot Replace @Data

Not every @Data class should become a record. Scenarios where records fall short:

Mutable objects required : JPA entities loaded from databases, domain objects built stepwise across multiple stages — these need mutation; record immutability is a hindrance.

Inheritance hierarchies : @Data works on regular classes that can extend and be extended; records cannot extend other classes nor be extended.

Heavy reliance on @Builder : When many parameters are optional and chained construction is valuable, @Builder saves massive boilerplate; records have no equivalent syntax sugar — manual static factories become verbose.

Legacy getXxx() naming contracts : If frontends or other services hard-depend on JSON field names derived from getSku -style getters, switching to sku() may break the contract. Workaround: keep @Data or add @JsonProperty("sku") on components, losing the one-line simplicity.

Java 15 or earlier : Records were preview in 14/15 (requiring --enable-preview, unsuitable for production) and standardized in 16. Projects on older JDKs must stick with @Data.

Need for non-component instance fields : Records only allow component fields. Derived/cached fields (e.g., computed-once values) cannot be added unless made static or computed on demand. @Data offers more freedom.

In short: use records for "construct once, never modify, just carry data" objects — DTOs, config items, return values, tuples. Keep @Data for "modify after construction, participate in inheritance, or need builder convenience" objects.

6. Important Caveats

equals/hashCode cost : Both use all components/fields. Large collections or expensive comparisons increase cost equally — records have no special optimization.

No extra instance fields : Adding a "creation timestamp" means making it a component (required at construction), a static field (not per-instance), or a method. Per-instance non-component state is inexpressible.

Don't mix Lombok on records : @Data on a record fails to compile (needs setters but fields are final). @Getter / @Setter are redundant or erroneous.

Deserialization depends on parameter names : Jackson 2.12+ uses the canonical constructor; if compilation discards parameter names (e.g., obfuscation) or older Jackson is used, sku becomes arg0 and deserialization fails. Fix: annotate with @JsonCreator + @JsonProperty.

Collection contents remain mutable : List<String> tags cannot be reassigned, but tags.add("x") still works. For deep immutability, use List.copyOf or immutable collections.

No performance gain : Records and classes have identical runtime memory layout and JIT treatment. Adopt records for readability and immutability guarantees, not for imaginary performance benefits.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.