Java Records Tutorial with Examples


Java has been evolving continuously to simplify and improve the programming experience. One such notable advancement is the introduction of records in Java 14 and their standardization in Java 16. Records are particularly useful for creating immutable data structures, a concept that is pivotal in ensuring data integrity and clarity in Java applications.

Let's first understand the problem and how the Record feature provides the solution.


Java Records Tutorial with Examples

The Problem: Complexity and Boilerplate in Immutable Classes 

Before the introduction of records, creating immutable classes in Java involved a considerable amount of boilerplate code. Developers had to write explicit code for various components, even for simple data-holding classes. Let's consider a traditional approach to creating an immutable class in Java, often seen in scenarios like database results, query responses, or information exchange between services.

Traditional Immutable Class Example: User Before Java Records, representing an immutable User class required a significant amount of explicit coding for various components.

public class User {
    private final Long id;
    private final String firstName;
    private final String lastName;
    private final String email;

    public User(Long id, String firstName, String lastName, String email) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
    }

    public Long getId() { return id; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public String getEmail() { return email; }

    @Override
    public boolean equals(Object obj) {
        // Custom implementation of equals
    }

    @Override
    public int hashCode() {
        // Custom implementation of hashCode
    }

    @Override
    public String toString() {
        // Custom implementation of toString
    }
}

In this example, for a simple data class like User, developers had to manually implement the constructor, getters, and override equals, hashCode, and toString methods. This process becomes repetitive and error-prone, especially when dealing with multiple such data classes.

The Solution: Java Records

Java Records, introduced in Java 14, aims to solve these issues by providing a more concise and readable syntax for declaring classes that are meant to be simple data carriers.

Key Advantages of Using Records:

Reduced Boilerplate: Records automatically generate the constructor, getters, equals(), hashCode(), and toString() methods, significantly reducing the need for boilerplate code.

Immutability: Records create immutable data structures by default, making them ideal for use cases where data integrity is critical.

Clarity and Transparency: The intent of a record is clear – it is solely a carrier for its data. This transparency makes the code easier to read and maintain.

Ease of Use: Developers can define a record in a single line of code, making it much simpler and more efficient to create data-holding classes.

Java Record Example: User

Now, let's see how the same User class would be defined as a record:

public record User(Long id, String firstName, String lastName, String email) {}

With just one line of code, all the necessary components of the User class are automatically generated by Java. This simplification not only saves time but also makes the code more readable and maintainable.

Key Points About Java Records 

Concise Syntax: Records allow you to define a class with a minimalistic approach, significantly reducing boilerplate code. A record can be declared in a single line, automatically providing a constructor, getters, equals(), hashCode(), and toString() methods. 

public record User(Long id, String firstName, String lastName, String email) {}

Immutability: The fields in a record are implicitly final, meaning the values assigned to these fields cannot be altered once the record is constructed. This immutability enhances reliability and thread safety. 

Clarity and Transparency: The intent of a record is clear – it is solely a carrier for its data. This transparency makes the code easier to read and maintain.

Automatic Data Methods: Java automatically generates the most common methods needed in a data class, like equals(), hashCode(), and toString(), based on the record's fields. 

No Inheritance: Records cannot extend to other classes and are implicitly final. However, they can implement interfaces, providing some flexibility in their usage. 

Compact Constructor: Records support a compact constructor, allowing for the validation or normalization of field values without explicitly defining a traditional constructor. 

Serialization Support: Records are serializable by default, as long as all their components are also serializable. This makes records a convenient choice for data transfer objects. 

Pattern Matching Compatibility: Records are designed to work seamlessly with pattern matching, a feature that further simplifies data processing in Java. 

Limited Customization: While you can add static methods, and instance methods, or override the default methods in records, they do not support mutable states. The primary purpose of a record is to succinctly and safely represent data. 

Java Records Examples (Covered Most of the use cases)

Example 1: Basic Data Holding

Use case: Representing a simple immutable data structure. 

Record Definition:
public record User(Long id, String firstName, String lastName, String email) {}
Record Usage:
public class Main {
    public static void main(String[] args) {
        User user = new User(1L, "Alice", "alice@example.com");
        System.out.println(user);
    }
}

Example 2: Validation in Compact Constructor

Use case: Performing validation during record construction.

Record Definition:

public record Product(String name, double price) {
    public Product {
        if (price < 0) {
            throw new IllegalArgumentException("Price cannot be negative");
        }
    }
}

Usage:

public class Main {
    public static void main(String[] args) {
        Product product = new Product("Laptop", 999.99);
        System.out.println(product);
    }
}

Output:

Product[name=Laptop, price=999.99]

Example 3: Adding Custom Methods

Use case: Extending functionality with custom methods.

Record Definition:

public record Rectangle(double length, double width) {
    public double area() {
        return length * width;
    }
}

Usage:

public class Main {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle(5.0, 3.0);
        System.out.println("Area: " + rectangle.area());
    }
}

Output:

Area: 15.0

Example 4: Implementing Interfaces

Use case: Implementing an interface with a record.

Record Definition:

public record Coordinate(double x, double y) implements Comparable<Coordinate> {
    @Override
    public int compareTo(Coordinate other) {
        return Double.compare(this.x, other.x);
    }
}

Usage:

public class Main {
    public static void main(String[] args) {
        Coordinate point1 = new Coordinate(3.0, 4.0);
        Coordinate point2 = new Coordinate(2.0, 5.0);
        System.out.println("Comparison result: " + point1.compareTo(point2));
    }
}

Output:

Comparison result: 1

Example 5: Serialization

Use case: Serializing and deserializing records.

Record Definition:

import java.io.*;

public record SerializableUser(Long id, String name) implements Serializable {}

Usage:

public class Main {
    public static void main(String[] args) {
        SerializableUser user = new SerializableUser(1L, "Bob");

        // Serialize
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.ser"))) {
            out.writeObject(user);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Deserialize
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.ser"))) {
            SerializableUser deserializedUser = (SerializableUser) in.readObject();
            System.out.println(deserializedUser);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Output:

SerializableUser[id=1, name=Bob]

Example 6: Pattern Matching (Java 16+)

Use case: Using records with pattern matching, which simplifies instance checking and casting.

Record Definition:

public record Shape(String type) {}
public record Circle(double radius) extends Shape {
    public Circle() {
        super("Circle");
    }
}
public record Square(double side) extends Shape {
    public Square() {
        super("Square");
    }
}

Usage:

public class Main {
    public static void main(String[] args) {
        Shape shape = new Circle(5.0);

        if (shape instanceof Circle circle) {
            System.out.println("Circle with radius: " + circle.radius());
        } else if (shape instanceof Square square) {
            System.out.println("Square with side: " + square.side());
        }
    }
}

Output:

Circle with radius: 5.0

Example 7: Composite Records

Use case: Using records within records to create complex, nested data structures.

Record Definition:

public record Address(String street, String city, String country) {}
public record Person(String name, Address address) {}

Usage:

public class Main {
    public static void main(String[] args) {
        Address address = new Address("123 Main St", "Anytown", "USA");
        Person person = new Person("Alice", address);
        System.out.println(person);
    }
}

Output:

Person[name=Alice, address=Address[street=123 Main St, city=Anytown, country=USA]]

Example 8: Records with Collections

Use case: Using records with Java Collections Framework.

Record Definition:

import java.util.List;

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

Usage:

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        Team team = new Team("Developers", Arrays.asList("Alice", "Bob", "Charlie"));
        System.out.println(team);
    }
}

Output:

Team[name=Developers, members=[Alice, Bob, Charlie]]

Example 9: Records in Data Processing

Use case: Utilizing records in stream operations and data processing tasks.

Record Definition:

public record Transaction(String id, double amount) {}

Usage:

import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream<Transaction> transactions = Stream.of(
            new Transaction("TXN123", 100.0),
            new Transaction("TXN456", 200.0)
        );

        double totalAmount = transactions.mapToDouble(Transaction::amount).sum();
        System.out.println("Total Amount: " + totalAmount);
    }
}

Output:

Total Amount: 300.0

When to use Java Records

Here are key situations where using Java records is beneficial: 

1. Immutable Data Carriers 

When you need to create classes that solely act as data carriers without the need for additional behavior. 

Example: DTOs (Data Transfer Objects) in a layered architecture, where you just need to transport data from one layer to another. 

2. Simplifying Code with Less Boilerplate 

To reduce the verbosity of your code by avoiding the boilerplate of getters, setters, equals(), hashCode(), and toString() methods. 

Example: Creating simple model classes in a web application that interacts with a database or a REST API. 

3. Modeling Value-Based Classes

When the identity of an object is not important, but the value it holds is, making it a good candidate for value-based classes. 

Example: Creating a Point or Coordinate class where the equality is based on the values of the object rather than its identity. 

4. Data Processing and Tuples

For use cases where you need to temporarily group together a few values without creating a full-fledged class. 

Example: When working with stream operations or methods that need to return multiple values bundled together as a tuple. 

5. Pattern Matching

Records work well with pattern matching (introduced as a preview feature in Java 16), which can simplify your code in switch expressions or instanceof checks. 

Example: Using records with instanceof in switch cases to destructure the record into its components.

6. Transparent State Representation 

When you need a transparent and simple representation of the state without encapsulation. 

Example: Simple configurations or settings objects where the state is open and transparent. 

7. Functional Programming 

Records fit well in functional programming paradigms due to their immutability. 

Example: Using records in streams and lambda expressions where immutability is a key aspect. 

8. Serializable Objects 

When you need a simple way to create serializable objects without custom serialization logic. 

Example: Sending objects over a network or saving them in a file where custom serialization is not required.

Difference Between Class and Record in Java

Here is a complete guide on Difference Between Class and Record in Java

Comments