Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 003 / 100

Java records: data classes without boilerplate explained

Records replace hundreds of lines of getters, equals and hashCode with one declaration.

Expona Admin8 min read

The problem with traditional Java classes

If you've built enough Java applications, you've written the same class structure countless times: fields, getters, setters, equals, hashCode, and toString. A simple data class holding a user ID and email requires 30+ lines of boilerplate code. Java records: data classes without boilerplate explained is exactly what the language needed—and now, with records (introduced in Java 16, finalized in Java 21), you declare immutable data types in a single line instead.

This isn't just syntactic sugar. Records force immutability by default, eliminate entire categories of bugs, and make your intent crystal clear to anyone reading the code. For teams building distributed systems, microservices, or event-driven architectures, records are a natural fit for DTOs, domain events, and value objects.

Immutable fields and generated accessors

A record automatically makes all fields final and generates read-only accessors. There are no setters. This is intentional: records are meant for data transfer and immutable value types, not mutable entities.

java
public record OrderEvent(long orderId, String status, Instant timestamp) {
}

// Usage
OrderEvent event = new OrderEvent(1001L, "SHIPPED", Instant.now());
System.out.println(event.orderId());      // Accessor is a method, not a field
System.out.println(event.status());
System.out.println(event.timestamp());

// This won't compile — no setter
// event.status = "DELIVERED";

The accessor methods are instance methods (not static), so you call event.orderId(), not event.getOrderId(). This is a deliberate break from JavaBean conventions. The bytecode is identical to a hand-written getter, so there's no performance penalty.

Each field becomes immutable by virtue of the record declaration. In a multi-threaded service handling thousands of concurrent requests, immutability eliminates entire classes of race conditions and synchronization bugs. Once an OrderEvent is created, nobody can accidentally mutate its state.

Automatic equals, hashCode, and toString

The Java compiler generates three critical methods for every record: equals, hashCode, and toString. Each is implemented sensibly and consistently.

java
public record Money(BigDecimal amount, String currency) {
}

// All three methods are generated
Money usd1 = new Money(new BigDecimal("100.00"), "USD");
Money usd2 = new Money(new BigDecimal("100.00"), "USD");

System.out.println(usd1.equals(usd2));     // true (all fields compared)
System.out.println(usd1.hashCode() == usd2.hashCode()); // true (consistent)
System.out.println(usd1.toString());       // Money[amount=100.00, currency=USD]

// Usable in collections immediately
Set<Money> wallet = new HashSet<>();
wallet.add(usd1);
wallet.add(usd2);  // Not added — equals says they're the same
System.out.println(wallet.size());  // 1

The generated toString() follows a consistent pattern: ClassName[field1=value1, field2=value2]. For debugging, this is vastly better than the default Object.toString() output. The hashCode is computed from all fields, making records safe to use directly as HashMap keys or in HashSet collections without hand-coding hash functions.

Because equals is based on field values (not identity), two records with the same data are considered equal—even if they're different objects. This is the semantic you want for value types like Money or Email, but it's different from entities where identity matters.

Perfect for DTOs, events, and value objects

Records shine in three common architectural patterns: data transfer objects, domain events, and immutable value objects.

DTOs for API contracts

When you serialize JSON to a REST endpoint, you need a class to bind incoming data. Records eliminate the boilerplate:

java
// Spring Boot controller
public record CreateOrderRequest(
    long customerId,
    List<String> itemIds,
    String shippingAddress
) {
}

@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody CreateOrderRequest req) {
    // req is immutable, safe to pass around
    return orderService.create(req.customerId(), req.itemIds(), req.shippingAddress());
}

Jackson (Spring's default JSON mapper) handles records out of the box. No annotations needed. The resulting object is immutable, thread-safe, and serializable.

Domain events for event sourcing and messaging

In event-driven systems, events should be immutable snapshots of what happened. Records are ideal:

java
public record PaymentProcessedEvent(
    long orderId,
    BigDecimal amount,
    String transactionId,
    Instant processedAt
) {
}

public record OrderShippedEvent(
    long orderId,
    String trackingNumber,
    Instant shippedAt
) {
}

// Publish to Kafka, store in event log, serialize to database
// Immutability guarantees the event never changes after creation

Your event store or message broker receives truly immutable events. No hidden mutable state, no surprises downstream.

Value objects in domain-driven design

In domain-driven design, a Money or PhoneNumber is a value object—it has no identity outside its data. Records are the natural representation:

java
public record PhoneNumber(String countryCode, String areaCode, String lineNumber) {
}

public record Email(String address) {
}

public class Customer {
    private final long id;  // Entity identity
    private final Email email;  // Value object (immutable)
    private final PhoneNumber phone;  // Value object (immutable)
    
    // Constructor and methods...
}

Value objects compare by content (via the generated equals), so two Email records with the same address are equal. This is exactly the semantics you need.

Add validation in the compact constructor

Records support a compact constructorbefore field assignment, so you can throw exceptions to reject invalid data:

java
public record Money(BigDecimal amount, String currency) {
    // Compact constructor — no parameter list
    public Money {
        if (amount == null) throw new IllegalArgumentException("amount cannot be null");
        if (amount.signum() < 0) throw new IllegalArgumentException("amount cannot be negative");
        if (currency == null || currency.isBlank()) {
            throw new IllegalArgumentException("currency must be provided");
        }
        // After validation, fields are assigned automatically
    }
}

// Usage
Money valid = new Money(new BigDecimal("50.00"), "EUR");  // OK

// These throw exceptions
// new Money(new BigDecimal("-10.00"), "USD");  // IllegalArgumentException
// new Money(new BigDecimal("50.00"), "");      // IllegalArgumentException

The compact constructor has no parameter list or return type—the compiler synthesizes both. Validation happens on every construction, ensuring no Money object can exist in an invalid state. This is far more maintainable than builder patterns or factory methods cluttered with validation checks.

You can also define a full canonical constructor if you need more complex logic:

java
public record Price(BigDecimal value, Currency currency) {
    // Full canonical constructor
    public Price(BigDecimal value, Currency currency) {
        this.value = (value != null) ? value : BigDecimal.ZERO;
        this.currency = (currency != null) ? currency : Currency.getInstance("USD");
    }
}

Not for JPA entities — why records and ORM don't mix

Records are intentionally immutable and final. JPA entities, on the other hand, require mutability (setters for lazy loading, dirty checking) and the ability to subclass (for proxies and lazy loading). Never use records as JPA entities.

If you try, you'll hit runtime errors or lost updates:

java
// DON'T DO THIS
@Entity
public record User(
    @Id long id,
    String email,
    String name
) {
}

// Problems:
// 1. Hibernate creates proxies that subclass the entity — can't subclass a record
// 2. Lazy loading requires setters — records have none
// 3. Dirty checking requires mutability — records are immutable
// 4. equals based on data values breaks entity identity semantics

For JPA, stick with traditional mutable classes. For everything else—DTOs, value objects, events, configuration classes—records eliminate boilerplate and enforce immutability from the language level.

That said, you can use records as projections or query results:

java
// JPA projection — not an entity
public record UserSummary(long id, String email) {
}

@Query("SELECT new com.myapp.UserSummary(u.id, u.email) FROM User u")
List<UserSummary> findAllSummaries();

Common mistakes

Mistake 1: Trying to mutate a record after construction. Records are immutable by design. If you need setters, you need a traditional class, not a record. Resist the urge to add setter methods to records—you'll break the contract.

Mistake 2: Using records for JPA entities. As discussed, ORM frameworks need mutability and inheritance. Use records for DTOs and value objects only.

Mistake 3: Forgetting to validate in the compact constructor. Just because a record is concise doesn't mean you should skip validation. Garbage in, garbage out. Always validate in the constructor.

Mistake 4: Overriding equals or hashCode without good reason. The generated implementations are almost always what you want. If you think you need custom logic, step back and reconsider whether a record is the right tool.

Mistake 5: Using records for mutable configuration objects. If your class needs to change state over time (config reloading, runtime adjustments), use a traditional class with setters, not a record.

Key takeaways

  • Records are immutable, final data classes with compiler-generated equals, hashCode, and toString. They're perfect for DTOs, events, and value objects.
  • Accessor methods are instance methods (orderId(), not getOrderId()) and all fields are final. No setters.
  • Compact constructors provide a clean way to validate fields before object construction, ensuring no invalid state escapes.
  • Generated equals compares by value (all fields), making records safe for collections and immutability-based equality semantics.
  • Never use records as JPA entities; they lack the mutability and inheritance flexibility that ORMs require.
  • Use records liberally for data transfer, configuration snapshots, and domain events. They reduce cognitive load and eliminate entire categories of boilerplate bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles