Java: Using Records and Immutable Data Models Effectively

Introduction

Java applications often contain classes whose primary purpose is to carry data between layers. Traditional implementations can require considerable boilerplate for constructors, accessors, equality, hashing, and string representations.

Java records reduce this boilerplate while making value-based data modeling explicit.

What Is a Record?

A record declares a fixed set of components.

public record Customer(
    long id,
    String name,
    String email
) {}

The compiler provides a canonical constructor, component accessors, and implementations of equals, hashCode, and toString.

Accessors use the component names:

Customer customer = new Customer(
    42L,
    "Sam Taylor",
    "sam@example.com"
);

System.out.println(customer.name());
System.out.println(customer.email());

Unlike a conventional JavaBean, the accessors are name() and email(), rather than getName() and getEmail().

Understand Shallow Immutability

Record components cannot be reassigned after construction, but referenced objects may still be mutable.

Consider this example:

import java.util.List;

public record Team(
    String name,
    List<String> members
) {}

The record prevents reassignment of its members reference, but it does not prevent the original list from being modified elsewhere.

A defensive copy can protect the collection:

import java.util.List;
import java.util.Objects;

public record Team(
    String name,
    List<String> members
) {
    public Team {
        Objects.requireNonNull(name);
        members = List.copyOf(members);
    }
}

List.copyOf creates an unmodifiable list and rejects null elements. If a record contains other mutable objects, determine whether those objects also need defensive copying.

Validate Values at Construction

Records work well for data with clear invariants.

import java.math.BigDecimal;
import java.util.Objects;

public record Money(
    BigDecimal amount,
    String currency
) {
    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);

        if (currency.isBlank()) {
            throw new IllegalArgumentException(
                "Currency is required"
            );
        }

        if (amount.signum() < 0) {
            throw new IllegalArgumentException(
                "Amount cannot be negative"
            );
        }
    }
}

This example illustrates constructor validation. Whether negative amounts are valid depends on the domain, and actual monetary rules may also need currency-specific precision and rounding behavior.

The broader principle is that invalid data should be rejected at a clear boundary.

Use Records for Data Transfer Objects

Records are particularly useful for API responses and application-layer data transfer objects.

public record ProductResponse(
    long id,
    String name,
    String description
) {}

A controller can map an internal entity to this response type instead of exposing the persistence model directly.

This separation allows the API contract to evolve independently and reduces the risk of accidentally returning internal fields.

Serialization behavior still depends on the framework and its configuration, so test record serialization with the versions used by your application.

When a Regular Class Is Better

Records are not suitable for every model. A traditional class may be preferable when you need:

  • Mutable state with controlled transitions.
  • Complex lifecycle behavior.
  • Identity-based equality.
  • Inheritance from another class.
  • Framework-specific behavior that records do not support.
  • A model whose public contract must evolve differently from its data components.

Records are implicitly final, so they cannot be extended as base classes. Their equality is based on their components, which may not match the identity rules of a database entity.

For example, two entities with identical field values may represent different persisted objects. Value-based equality may be inappropriate for that model.

Records and Domain Models

A practical design often uses regular classes for domain entities and records for immutable values or data transfer objects.

An entity may have a stable identifier and methods that enforce business rules as its state changes. A value object, such as a validated coordinate or date range, may be naturally represented by a record.

Choose based on semantics rather than merely on which approach requires less code.

Test Record Invariants

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import java.util.List;

class TeamTest {
    @Test
    void membersCannotBeModifiedThroughTheRecord() {
        Team team = new Team(
            "Platform",
            List.of("Alex", "Jordan")
        );

        assertThrows(
            UnsupportedOperationException.class,
            () -> team.members().add("Morgan")
        );
    }
}

Also test null handling, invalid values, normalization, and any defensive-copying behavior.

Conclusion

Java records provide a concise way to model data with value-based equality. They are especially useful for DTOs and immutable value objects.

Remember that records provide shallow immutability and value semantics—not automatic domain correctness. Use them when those properties match the model you want to express.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *