How do I create a record to store data in Java?

A record is a concise way to define a class that holds data. Java automatically provides a constructor, accessor methods, and implementations of equals(), hashCode(), and toString().

In this example, you will create a Book record to store a title and an author.

Source code

Save the following code as RecordExample.java:

public class RecordExample {
    void main() {
        Book firstBook = new Book("Learning Java", "Alice");
        Book secondBook = new Book("Learning Java", "Alice");

        IO.println("Title: " + firstBook.title());
        IO.println("Author: " + firstBook.author());
        IO.println("Record: " + firstBook);
        IO.println("Equal values: " + firstBook.equals(secondBook));
        IO.println("Same object: " + (firstBook == secondBook));
    }
}

record Book(String title, String author) {
}

Explanation

The following declaration defines a record with two components:

record Book(String title, String author) {
}

The components are title and author, both of type String.

For this declaration, Java automatically provides:

Member Purpose
Book(String title, String author) Initializes the components
title() Returns the title
author() Returns the author
equals(Object other) Compares component values
hashCode() Calculates a hash code from the components
toString() Returns a text representation of the record

Creating a record object

Use the new keyword and pass a value for each component:

Book firstBook = new Book("Learning Java", "Alice");

The arguments follow the order of the components in the record declaration.

Reading component values

Record accessors use the component names directly:

firstBook.title()
firstBook.author()

The generated methods are named title() and author(), rather than getTitle() and getAuthor().

Each component has a private final field. Records do not generate setters, so you cannot reassign these fields after construction.

Comparing records

The example creates two separate objects with the same component values:

Book firstBook = new Book("Learning Java", "Alice");
Book secondBook = new Book("Learning Java", "Alice");

The generated equals() method returns true because both objects are instances of the same record class and have equal component values.

The == operator returns false because the variables refer to separate objects.

Printing a record

Passing the record to IO.println() uses its generated toString() method:

IO.println("Record: " + firstBook);

The result includes the record name, component names, and their values.

Run the example

Compile and run the file:

javac RecordExample.java
java RecordExample

Output

Title: Learning Java
Author: Alice
Record: Book[title=Learning Java, author=Alice]
Equal values: true
Same object: false

Are records immutable?

Records are shallowly immutable: their component fields cannot be reassigned, but referenced objects may still be mutable.

This Book record contains only strings, which are immutable. If a record contains a mutable list or array, additional steps are needed to protect that data from changes.

How do I protect a List inside a Java record?

A record is a convenient way to represent data, but a record containing a mutable list can still change unexpectedly. In this tutorial, you will protect a reading list from changes made through other references.

Records became a final language feature in Java 16. This tutorial uses Java 17 as its baseline and requires no preview or incubator flags. See Java’s language changes by release.

1. Understand the problem

Consider this record:

record ReadingList(String title, List<String> books) {
}

Java generates its constructor and accessors. However, the generated constructor stores the supplied list reference. A caller holding that list can change its contents afterward.

A final field prevents reassignment of the reference; it does not freeze the referenced object. Java’s record documentation explains the fields and methods generated for records.

2. Protect the list during construction

Use a compact constructor to replace the incoming list with an unmodifiable snapshot:

ReadingList {
    books = List.copyOf(books);
}

Assign to the constructor parameter, books. Java assigns the resulting value to the component field after the constructor body completes.

List.copyOf() preserves element order and prevents later changes to the original list from affecting the result. It rejects null collections and null elements. See the Java 17 List API.

3. Create the complete example

Save this code as RecordListExample.java:

import java.util.ArrayList;
import java.util.List;

public class RecordListExample {
    record ReadingList(String title, List<String> books) {
        ReadingList {
            if (title == null || title.isBlank()) {
                throw new IllegalArgumentException(
                        "Title must not be blank");
            }
            books = List.copyOf(books);
        }
    }

    public static void main(String[] args) {
        List<String> original =
                new ArrayList<>(List.of("Java Basics"));

        ReadingList readingList =
                new ReadingList("Weekend", original);

        original.add("Advanced Java");

        System.out.println("Original: " + original);
        System.out.println("Record: " + readingList.books());

        try {
            readingList.books().add("Another Book");
        } catch (UnsupportedOperationException exception) {
            System.out.println("Record list cannot be modified");
        }
    }
}

The example checks two paths: modifying the original list and attempting a modification through the record’s accessor.

4. Compile and run

Open a terminal in the directory containing the file:

javac --release 17 RecordListExample.java
java RecordListExample

Output:

Original: [Java Basics, Advanced Java]
Record: [Java Basics]
Record list cannot be modified

The original list accepts another book. The record keeps its initial contents, and its returned list rejects additions.

5. Know the limit: elements are not copied

This approach protects the list structure. It does not create separate copies of its elements.

Here, the elements are strings, which are immutable. If the list contained mutable Book objects, callers could still change those objects. Use immutable element types or copy mutable elements when your data model requires deeper protection.

Best practices

  • Copy incoming collections at construction boundaries.
  • Document your null policy; this example rejects null lists and null elements.
  • Prefer immutable element types for value objects.
  • Avoid unnecessary copying in the accessor when the stored list is already protected.
  • Treat the caught exception as a demonstration; normal application code should avoid attempting unsupported modifications.

Summary

A record does not automatically make a referenced list immutable. Using List.copyOf() in its compact constructor protects the list against external structural changes while keeping the record concise.

How do I write tests for Java records?

Java records are compact classes designed to hold immutable data. Because records automatically provide a constructor, accessor methods, equals(), hashCode(), and toString(), testing them is usually simpler than testing ordinary classes.

In most cases, you do not need to test Java’s generated record behavior directly. Instead, test:

  • custom validation in the compact constructor
  • custom methods you add to the record
  • behavior that depends on equality or immutability
  • serialization/deserialization if the record is used with JSON or persistence frameworks

Example Record

Suppose you have this Java record:

public record User(String username, String email, int age) {

    public User {
        if (username == null || username.isBlank()) {
            throw new IllegalArgumentException("Username must not be blank");
        }

        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Email must be valid");
        }

        if (age < 0) {
            throw new IllegalArgumentException("Age must not be negative");
        }
    }

    public boolean isAdult() {
        return age >= 18;
    }
}

This record has:

  • three components: username, email, and age
  • validation in the compact constructor
  • a custom method named isAdult()

Basic JUnit 5 Test Class

Here is a simple JUnit 5 test class:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class UserTest {

    @Test
    void shouldCreateUserWithValidData() {
        User user = new User("alice", "[email protected]", 25);

        assertEquals("alice", user.username());
        assertEquals("[email protected]", user.email());
        assertEquals(25, user.age());
    }

    @Test
    void shouldReturnTrueWhenUserIsAdult() {
        User user = new User("bob", "[email protected]", 20);

        assertTrue(user.isAdult());
    }

    @Test
    void shouldReturnFalseWhenUserIsNotAdult() {
        User user = new User("charlie", "[email protected]", 15);

        assertFalse(user.isAdult());
    }

    @Test
    void shouldRejectBlankUsername() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("", "[email protected]", 25)
        );

        assertEquals("Username must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectInvalidEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("alice", "invalid-email", 25)
        );

        assertEquals("Email must be valid", exception.getMessage());
    }

    @Test
    void shouldRejectNegativeAge() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("alice", "[email protected]", -1)
        );

        assertEquals("Age must not be negative", exception.getMessage());
    }
}

Testing Generated Accessor Methods

Record accessors use the component name directly. For example, if your record is:

public record Product(String name, double price) {
}

The accessors are:

product.name();
product.price();

not:

product.getName();
product.getPrice();

A basic test looks like this:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProductTest {

    @Test
    void shouldExposeRecordComponents() {
        Product product = new Product("Keyboard", 49.99);

        assertEquals("Keyboard", product.name());
        assertEquals(49.99, product.price());
    }
}

However, for plain records with no validation or custom behavior, these tests often provide little value because they only verify Java-generated code.


Testing equals() and hashCode()

Records automatically generate equals() and hashCode() based on all record components.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

class ProductTest {

    @Test
    void shouldCompareRecordsByComponentValues() {
        Product first = new Product("Keyboard", 49.99);
        Product second = new Product("Keyboard", 49.99);
        Product third = new Product("Mouse", 19.99);

        assertEquals(first, second);
        assertEquals(first.hashCode(), second.hashCode());
        assertNotEquals(first, third);
    }
}

Again, you usually do not need this test unless your application depends heavily on equality behavior, such as using records as keys in a Map or elements in a Set.


Testing toString()

Records also generate a readable toString() method:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProductTest {

    @Test
    void shouldGenerateReadableToString() {
        Product product = new Product("Keyboard", 49.99);

        assertEquals("Product[name=Keyboard, price=49.99]", product.toString());
    }
}

Be careful with this kind of test. It can be brittle because it depends on the exact string format.

Test toString() mainly when:

  • you override it
  • logs or messages depend on its output
  • the string representation is part of your expected behavior

Testing Constructor Validation

Records are commonly used with compact constructors for validation.

public record EmailAddress(String value) {

    public EmailAddress {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Email must not be blank");
        }

        if (!value.contains("@")) {
            throw new IllegalArgumentException("Email must contain @");
        }
    }
}

Test both valid and invalid cases:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class EmailAddressTest {

    @Test
    void shouldCreateEmailAddressWhenValueIsValid() {
        EmailAddress email = new EmailAddress("[email protected]");

        assertEquals("[email protected]", email.value());
    }

    @Test
    void shouldRejectNullEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(null)
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectBlankEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(" ")
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectEmailWithoutAtSign() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress("invalid-email")
        );

        assertEquals("Email must contain @", exception.getMessage());
    }
}

Using Parameterized Tests for Records

Parameterized tests are useful when a record has multiple invalid input values.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertThrows;

class EmailAddressTest {

    @ParameterizedTest
    @ValueSource(strings = {"", " ", "invalid-email", "user.example.com"})
    void shouldRejectInvalidEmailValues(String value) {
        assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(value)
        );
    }
}

For more complex data, use @CsvSource:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertThrows;

class UserTest {

    @ParameterizedTest
    @CsvSource({
            "'', [email protected], 25",
            "' ', [email protected], 25",
            "alice, invalid-email, 25",
            "alice, [email protected], -1"
    })
    void shouldRejectInvalidUserData(String username, String email, int age) {
        assertThrows(
                IllegalArgumentException.class,
                () -> new User(username, email, age)
        );
    }
}

Testing Custom Methods in Records

If your record contains business logic, test that logic directly.

public record Money(String currency, int amount) {

    public boolean isPositive() {
        return amount > 0;
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency())) {
            throw new IllegalArgumentException("Currencies must match");
        }

        return new Money(currency, amount + other.amount());
    }
}

Tests:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class MoneyTest {

    @Test
    void shouldReturnTrueForPositiveAmount() {
        Money money = new Money("USD", 100);

        assertTrue(money.isPositive());
    }

    @Test
    void shouldAddMoneyWithSameCurrency() {
        Money first = new Money("USD", 100);
        Money second = new Money("USD", 50);

        Money result = first.add(second);

        assertEquals(new Money("USD", 150), result);
    }

    @Test
    void shouldRejectAddingDifferentCurrencies() {
        Money first = new Money("USD", 100);
        Money second = new Money("EUR", 50);

        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> first.add(second)
        );

        assertEquals("Currencies must match", exception.getMessage());
    }
}

Testing Immutability

Records are shallowly immutable. This means record components cannot be reassigned, but if a component refers to a mutable object, that object can still be changed.

Example:

import java.util.List;

public record Order(List<String> items) {
}

This record is not deeply immutable:

import java.util.ArrayList;
import java.util.List;

Order order = new Order(new ArrayList<>(List.of("Book")));
order.items().add("Pen");

To make it safer, copy the list:

import java.util.List;

public record Order(List<String> items) {

    public Order {
        items = List.copyOf(items);
    }
}

Then test it:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class OrderTest {

    @Test
    void shouldDefensivelyCopyItems() {
        List<String> items = new ArrayList<>();
        items.add("Book");

        Order order = new Order(items);

        items.add("Pen");

        assertEquals(List.of("Book"), order.items());
    }

    @Test
    void shouldExposeUnmodifiableItems() {
        Order order = new Order(new ArrayList<>(List.of("Book")));

        assertThrows(
                UnsupportedOperationException.class,
                () -> order.items().add("Pen")
        );
    }
}

This is a valuable test because it verifies your own defensive-copying behavior, not just Java-generated record behavior.


What Should You Actually Test?

For Java records, focus your tests on behavior you wrote yourself.

Good things to test:

Feature Should You Test It? Why
Accessor methods Usually no Generated by Java
equals() / hashCode() Sometimes Useful if equality is important in your domain
toString() Rarely Usually generated and brittle to assert
Compact constructor validation Yes This is your logic
Custom methods Yes This is your logic
Defensive copying Yes Important for immutability
Serialization/deserialization Yes, if used Important for APIs and persistence

Recommended Testing Style

Use clear test names:

@Test
void shouldRejectNegativeAge() {
    // test body
}

Follow the Arrange-Act-Assert pattern:

@Test
void shouldCreateUserWithValidData() {
    // Arrange
    String username = "alice";
    String email = "[email protected]";
    int age = 25;

    // Act
    User user = new User(username, email, age);

    // Assert
    assertEquals(username, user.username());
    assertEquals(email, user.email());
    assertEquals(age, user.age());
}

Summary

To test Java records:

  1. Do not over-test generated code.
  2. Test constructor validation.
  3. Test custom methods.
  4. Test defensive copying for mutable components.
  5. Test JSON or persistence integration only when records are used that way.
  6. Use JUnit 5 assertions such as assertEquals(), assertTrue(), assertFalse(), and assertThrows().

A plain record like this usually needs no dedicated unit test:

public record Point(int x, int y) {
}

But a record like this should be tested:

public record Age(int value) {

    public Age {
        if (value < 0) {
            throw new IllegalArgumentException("Age must not be negative");
        }
    }

    public boolean isAdult() {
        return value >= 18;
    }
}

Because it contains behavior that belongs to your application, not just Java’s generated record features.

How do I use ObjectOutputStream with record?

To use ObjectOutputStream with a Java record, you need to make the record implement the java.io.Serializable interface.

One of the great things about records is that they are designed to be “data carriers,” and Java’s serialization mechanism handles them more robustly and securely than regular classes. Specifically, records are serialized using only their components (the fields defined in the header), and the deserialization process uses the record’s canonical constructor, ensuring that any validation logic you’ve placed there is always executed.

Here is a complete example of how to write a record to a file and read it back:

1. Define the Record

Make sure it implements Serializable.

package org.kodejava.io;

import java.io.Serializable;

/**
 * A simple record representing a Person.
 * Records are implicitly final and their fields are private and final.
 */
public record Person(String name, int age) implements Serializable {
    // Compact constructor for validation
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }
}

2. Serialize and Deserialize

Use ObjectOutputStream to write the object and ObjectInputStream to read it.

package org.kodejava.io;

import java.io.*;

public class RecordSerializationDemo {
    public static void main(String[] args) {
        String filename = "person.ser";
        Person person = new Person("John Doe", 30);

        // 1. Serialize the record
        try (FileOutputStream fos = new FileOutputStream(filename);
             ObjectOutputStream oos = new ObjectOutputStream(fos)) {

            oos.writeObject(person);
            System.out.println("Record saved: " + person);

        } catch (IOException e) {
            e.printStackTrace();
        }

        // 2. Deserialize the record
        try (FileInputStream fis = new FileInputStream(filename);
             ObjectInputStream ois = new ObjectInputStream(fis)) {

            Person savedPerson = (Person) ois.readObject();
            System.out.println("Record loaded: " + savedPerson);

        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Key Points to Remember:

  • Immutability: Since records are immutable, serialization is very straightforward.
  • No serialVersionUID Required (mostly): While you can define a serialVersionUID, Java’s serialization for records ignores the field-matching rules that usually require it. The serialization is based strictly on the component names.
  • Security: Records are less susceptible to “deserialization attacks” because they don’t allow the creation of “ghost” objects; they must go through the canonical constructor.
  • Customization: Records do not support writeObject, readObject, readObjectNoData, or writeExternal methods. If you need custom serialization logic, you should use a regular class instead.

How to use record patterns with instanceof in Java 25

Java 25 introduces improvements such as record patterns with instanceof, which allow more concise and expressive type matching and data extraction in one step. Here’s a guide on how to use them:


What are record patterns?

A record pattern enables matching and extracting components of a record class, which is essentially a class with immutable data. Record patterns simplify operations by combining type checking and field extraction syntactically.


Using instanceof with Record Patterns

In Java 25, you can use a record pattern directly with instanceof to both:
1. Match the type of the object.
2. Decompose its contents in a single expression.


Example of Record Patterns with instanceof

record Point(int x, int y) {}

public class Main {
    public static void main(String[] args) {
        Object obj = new Point(10, 20);

        // Using instanceof with a record pattern
        if (obj instanceof Point(int x, int y)) {
            System.out.println("Point coordinates: x = " + x + ", y = " + y);
        } else {
            System.out.println("Not a Point object");
        }
    }
}

Explanation

  • obj instanceof Point(int x, int y):
    • Pattern Matching: Verifies if obj is an instance of the Point record.
    • Decomposition: Extracts the x and y fields of the record into variables x and y.

As a result:

  • If obj matches the type, the fields are extracted automatically in the same step.
  • There’s no need to cast obj to Point explicitly or manually call getters.

Nesting Record Patterns

Record patterns can also be nested for more complex records containing other records or collections.

Example: Nested Record Patterns

record Rectangle(Point topLeft, Point bottomRight) {}

public class Main {
    public static void main(String[] args) {
        Object obj = new Rectangle(new Point(0, 0), new Point(10, 10));

        if (obj instanceof Rectangle(Point(int x1, int y1), Point(int x2, int y2))) {
            System.out.println("Rectangle corners: (" + x1 + ", " + y1 + ") to (" + x2 + ", " + y2 + ")");
        } else {
            System.out.println("Not a Rectangle object");
        }
    }
}

Explanation

  • Rectangle(Point(int x1, int y1), Point(int x2, int y2)) is a nested pattern:
    • Matches top-level Rectangle.
    • Decomposes its topLeft and bottomRight fields into Point objects.
    • Further extracts x and y coordinates from each Point.

Benefits

  1. Conciseness: Eliminates the need for explicit casting or redundant getter calls.
  2. Readability: Patterns declaratively show what is being matched and extracted.
  3. Flexibility: Works seamlessly with nested structures.

Good-to-Know Details

  1. Exhaustive Matching: Combine switch with record patterns for exhaustive, cleaner matching:
    void printShapeInfo(Object shape) {
       switch (shape) {
           case Point(int x, int y) -> System.out.println("Point: (" + x + ", " + y + ")");
           case Rectangle(Point topLeft, Point bottomRight) -> System.out.println("Rectangle with corners: " +
                   topLeft + " to " + bottomRight);
           default -> System.out.println("Unknown shape");
       }
    }
    
  2. Null Handling: instanceof with patterns doesn’t match null values directly. An explicit null check is still required.

  3. Restrictions: The immutability of records ensures safety and predictability when decomposing data and matching patterns.


Conclusion

The introduction of record patterns in Java 25 significantly enhances pattern matching and makes working with immutable objects far more intuitive and concise. Whether you’re matching simple records or nested structures, this feature saves you from boilerplate code and improves code readability.