Records

Quick Summary:

  • A new kind of type declaration.
  • For those situations where we make classes that serve just to shallowly hold data.
  • To reduce boilerplate by automatically generating constructors, getters, equals & hashCode and toString methods.
  • Released in Java 16 (2021), but introduced in Java 14 (2020).

Key Features

  • Simplified Syntax: Records introduce a new type declaration syntax that is more concise than traditional classes. You only need to declare the type’s name and its state components.
  • Automatic Generation of Boilerplate Code: For each record, the Java compiler automatically generates a public constructor, private final fields for each component, and public methods to access these fields (getters).
  • Built-in Implementations: Records come with automatically generated implementations of equals(), hashCode(), and toString() methods that consider all components of the record, facilitating value-based equality checks and easy data representation.
  • Restrictions on Records: Records cannot extend any other class and cannot be extended themselves, ensuring immutability and data consistency. They can, however, implement interfaces.
  • Local Records: Java allows defining records locally within methods, enhancing the capability to encapsulate transient data structures specific to a block of code.

Purpose

  • Reduce Boilerplate: Records reduce the need for verbose code, automatically handling common methods like equals(), hashCode(), and toString(), which are typically manually overridden in data-centric classes.
  • Data Modeling Clarity: By using records, developers can express their intention to use a particular class solely as a data carrier, making the codebase easier to understand and maintain.
  • Immutability Assurance: Records ensure that the data they hold is immutable by design, which enhances safety, especially in concurrent environments. Immutable objects are easier to debug and work with in multi-threaded scenarios.
  • Promote Data Transparency: With all data fields final and implicitly public (via public getters), records provide a clear API for data access, which promotes better integration and less error-prone code.

Example

In this example, we create a simple class to represent a product in an inventory system.

Before Java 17:

Developers would have to add a lot of boilerplate code for what is just a simple model class.

public class Product {
    private Long id;
    private String name;
    private double price;

    public Product(Long id, String name, double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Product product = (Product) o;
        return Double.compare(price, product.price) == 0 && id.equals(product.id) && name.equals(product.name);
    }

    @Override
    public int hashCode() {
        int result = id.hashCode();
        result = 31 * result + name.hashCode();
        result = 31 * result + Double.hashCode(price);
        return result;
    }

    @Override
    public String toString() {
        return "Product{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", price=" + price +
                '}';
    }
}

After Java 17:

With records everything becomes much simpler.

public record Product(Long id, String name, double price) {
}
  • This example creates a record Product with three components: id, name and price.
  • The Java compiler automatically generates a constructor, as well as the methods id(), name(), price(), equals(), hashCode(), and toString().

Creating Records

Making a new record is similar to writing a class, interface etc. but there are some caveats:

  1. Declare the record: Use keyword record.
  2. Declare the components: Follow the name up with parentheses wherein you declare the components/fields, similar to a constructor.
public record Product(Long id, String name, double price) {
}
  1. Optionally, add logic: The body of the record can remain empty, but you can also add additional methods and static members.
public record Product(Long id, String name, double price) {
    public double applyDiscount(double discount) {
        return price * (1 - discount);
    }
}
  1. Optionally, add logic to constructor: You can include a compact constructor if you need to validate arguments or otherwise run initialisation logic.
public record Product(Long id, String name, double price) {
    public Product {
        if (id == null) throw new IllegalArgumentException("id is null");
        if (name == null) throw new IllegalArgumentException("name is null");
        if (price < 0) throw new IllegalArgumentException("price is negative");
    }
}
  • âš  Records are Final: Records are implicitly final in Java. This means a record cannot be subclassed. This design decision is intentional to keep the data model simple and consistent, ensuring that the immutability and thread-safety characteristics of records are preserved.
// ❌ Cannot inherit from final
public class DigitalProduct extends Product {
}
  • âš  Records are Bastards: Records cannot inherit from any other class. They already implicitly extend the java.lang.Record-class which is the only one they’re allowed to extend.
// ❌ No extends clause allowed for record
public record Product(Long id, String name, double price) extends ProductBase {
}
  • âś… Records are Polymorphic: Records can implement any number of interfaces, unlike class extension. This ensures records can participate in polymorphic operations, similar to other classes that implement the same interfaces. Implementing an interface is exactly the same as with a class.
public record Product(Long id, String name, double price) implements Comparable<Product> {
    @Override
    public int compareTo(Product o) {
        return Long.compare(id, o.id);
    }
}

Using Records

Once a record is defined, using it is straightforward due to the simplicity and automation of the syntax.

  • Create record objects: Instantiating a record is similar to any object.
Product product = new Product(1L, "Coffee Maker", 99.95);
  • Get values: Acessing the fields of a record is also simple, as getters are auto-generated, just without the get-prefix we’re accustomed to. Instead, the name of the field on its own is used.
System.out.println("Product ID: " + product.id());
System.out.println("Product name: " + product.name());
System.out.println("Product price: " + product.price());
  • âš  Set values: All fields in a record are final and private, meaning they cannot be changed once set. This immutability is key for using records as reliable data carriers, especially in multi-threaded environments.
product.setPrice(89.99); // ❌ No setter, the fields are final aka immutable
  • Equals: Automatically checks if two record objects have the same values in all their fields. Something we typically have to write ourselves, but records auto-generate it.
Product product = new Product(1L, "Coffee Maker", 99.95);
Product sameProductDifferentObj = new Product(1L, "Coffee Maker", 99.95);

System.out.println(product.equals(sameProductDifferentObj)); // âś… true
System.out.println(product == sameProductDifferentObj); // ❌ false
  • toString: Provides a string representation of the record, including its name and all field values. Something we typically have to write ourselves, but records auto-generate it.
System.out.println(product);

👩‍💻 Hands-on Demo: Records

🔎 Click here to expand

1. Multiple Choice Quiz

Which of the following statements is true about Java records?

A) Records can extend other classes besides java.lang.Record.
B) Records can implement any number of interfaces.
C) Records allow mutable fields to be defined within them.
D) Records automatically generate private constructors only.

2. Fill-in-the-Blanks

  • Fill in the blanks to complete the code snippet that defines a record in Java for storing album information, including the artist name, album title, and release year.
record ___(String ___, String ___, int ___) {}

3. Debugging Challenge: Code Correction

Correct the code to ensure it adheres to Java’s record rules about immutability and initialization correctness..

record Instrument(String type, int year) {
    // Custom constructor for validation
    public Instrument {
        if (year < 1500) {
            throw new IllegalArgumentException("Year must be at least 1500.");
        }
        // Mistake in logic, instruments can't be of type "undefined"
        if (type == null || type.isEmpty()) {
            type = "undefined";  // This line should be fixed
        }
    }
}

4. Employees

We’ll model some employees using records!

  1. Create a new Employee-record with some basic components (first and last name, salary and job title).
  2. In the main-method of a EmployeeApp-class, make an Employee-object and have them introduce themselves by printing their full name and job title.
  3. Make a set of employees.
  4. Print each Employee-object directly to test the toString().
  1. Add another employee to the set, a new Employee-object that has the exact same field values as your original employee, just in a new object.
  2. In the for-loop where you print each employee, use the equals() method to compare each employee to the original employee.

5. Shapes

We’ll model some shapes using records to illustrate polymorphic behaviour.

  1. Create a Shape-interface that prescribes the methods calcPerimeter(): double and calcArea(): double.
  2. Create a Rectangle-record and Circle-record, providing each with appropriate components.
    • Ensure the constructor throws an exception if incoming arguments are invalid (i.e. zero or negative dimensions).
  3. Make it so they both implement the Shape-interface and its abstract methods.
  4. In a ShapesApp-class, create a list of rectangles and circles and then loop over each one, and print it as an object, along with its perimeter and area.

Solutions

🕵️‍♂️ Click here to reveal the solutions

1. Multiple Choice Quiz

Correct Answer: B) Records can implement any number of interfaces.

Explanation:

Records are final and cannot be subclassed beyond java.lang.Record, ensuring that they are simple and consistent in structure. They do not allow mutable fields, as all components of a record are implicitly final. The constructors generated by records are public, not private, and they can implement any number of interfaces, allowing for polymorphic behaviour.

2. Fill-in-the-Blanks

record ___(String ___, String ___, int ___) {}
record Album(String artist, String title, int releaseYear) {}

3. Debugging Challenge: Code Correction

Original:
record Instrument(String type, int year) {
    // Custom constructor for validation
    public Instrument {
        if (year < 1500) {
            throw new IllegalArgumentException("Year must be at least 1500.");
        }
        if (type == null || type.isEmpty()) {
            type = "undefined";
        }
    }
}
Corrected:
record Instrument(String type, int year) {
    // Custom constructor for validation
    public Instrument {
        if (year < 1500) {
            throw new IllegalArgumentException("Year must be at least 1500.");
        }
        if (type == null || type.isEmpty()) {
            throw new IllegalArgumentException("Type cannot be null or empty.");
        }
    }
}
Explanation:

The original code attempted to modify the type field directly, which is not allowed as record components are final. In the corrected version, the record uses a compact constructor with parameters to validate the inputs. It throws an exception if the type is invalid rather than trying to reset it, adhering to Java’s principles of immutability for records. This ensures that every Instrument object is correctly initialized with valid data.

4. Employees

public record Employee(String firstName, String lastName, double salary, String jobTitle) {
}
public class EmployeeApp {
    public static void main(String[] args) {
        Employee employee = new Employee("James", "Barnes", 70_655.32, "Scrum Master");
        System.out.printf("My name is %s %s and I am a %s.\n", employee.firstName(), employee.lastName(), employee.jobTitle());

        Set<Employee> staff = Set.of(
                new Employee("Vincent", "Lee", 45_000.0, "Vinyl Master"),
                new Employee("Ella", "Fitzgerald", 52_000.0, "Quality Control Specialist"),
                new Employee("Louis", "Armstrong", 56_000.0, "Design Lead"),
                new Employee("James", "Barnes", 70_655.32, "Scrum Master")
        );

        for (Employee e : staff) {
            System.out.println(e);
            System.out.printf("\t%s same as the original employee.\n", e.equals(employee) ? "The": "Not the");
        }
    }
}

5. Shapes

public interface Shape {
    double calcPerimeter();
    double calcArea();
}
public record Rectangle(double width, double height) implements Shape {
    public Rectangle {
        if (width <= 0) throw new IllegalArgumentException("width is negative or zero");
        if (height <= 0) throw new IllegalArgumentException("height is negative or zero");
    }

    @Override
    public double calcPerimeter() {
        return 2 * (width + height);
    }

    @Override
    public double calcArea() {
        return width * height;
    }
}
public record Circle(double radius) implements Shape {
    public Circle {
        if (radius <= 0) throw new IllegalArgumentException("radius is negative or zero");
    }

    @Override
    public double calcPerimeter() {
        return 2 * Math.PI * radius;
    }

    @Override
    public double calcArea() {
        return Math.PI * (radius * radius);
    }
}
public class ShapesApp {
    public static void main(String[] args) {
        List<Shape> shapes = List.of(
                new Circle(5),
                new Rectangle(3, 4),
                new Rectangle(4, 3)
        );

        for (Shape s : shapes) {
            System.out.println(s);
            System.out.printf("\tPerimeter: %.2f\n", s.calcPerimeter());
            System.out.printf("\tArea: %.2f\n", s.calcArea());
        }
    }
}

Creating and Using Records Locally in Methods

Using local records within a method can be particularly useful when you need to structure complex data temporarily, especially when dealing with operations that benefit from grouping related data without exposing it outside the method.

Example

    public AnimalExhibit findMostVisitedExhibitByMonth(List<AnimalExhibit> exhibits, int month) {

        // Local record for pairing an exhibit with its visitor count
        record ExhibitVisits(AnimalExhibit exhibit, int visits) {}

        return exhibits.stream()
                .map(exhibit -> new ExhibitVisits(exhibit, findVisitsCountForExhibitByMonth(exhibit, month)))
                .sorted((e1, e2) -> Integer.compare(e2.visits(), e1.visits()))
                .map(ExhibitVisits::exhibit)
                .findFirst().orElseThrow();
    }
  • Local Record ExhibitVisits: This local record ties an AnimalExhibit object with a visitor count (visits). The record is scoped within the findMostVisitedExhibitByMonth-method, ensuring that the pairing of exhibits and visitor statistics is managed internally and only relevant within this method.

Reality Check: Practical Uses of Java Records

Java records have been incorporated into various domains within enterprise development, each taking advantage of their streamlined, immutable nature. Here are key areas of use with short examples:

  • Data Transfer Objects (DTOs): Records are extensively used as DTOs for safely transferring data across service layers without side effects.
    • Example: record UserLoginDto(String username, String email) {}
  • Database Entities: In read-only data access scenarios, records offer a concise way to represent database entities or views.
    • Example: record CustomerView(String firstName, String lastName, long customerNumber) {}
    • âš  When using the popular JPA-framework, we strongly advise against using records. Read more here.
  • Immutable Collections: Records enhance the robustness of applications by providing safe shared data in concurrent processes.
    • Example: List<Product> products = List.of(new Product(1, "Tea", 1.99));
  • Messaging and Event-Driven Architectures: Using records to represent immutable events aligns well with ensuring data consistency in systems like event sourcing.
    • Example: record OrderCreatedEvent(long orderId, Date creationDate) {}
  • Functional Programming: Records support functional programming paradigms, enhancing code readability and maintainability in operations such as streams.
    • Example: Stream.of(new Person("John", "Doe")).filter(p -> p.name().equals("John"))
  • Configuration Properties: In microservices, records serve as a streamlined way to load and access configuration properties.
    • Example: record ServerConfig(String host, int port) {}
  • Caching Mechanisms: The immutability of records makes them ideal for use as keys in caches, where mutable keys could cause inconsistencies.
    • Example: Map<Product, ProductDetail> cache = new ConcurrentHashMap<>();

Summary

  • Goodbye Boilerplate: Records reduce boilerplate by auto-generating constructors, getters, and methods like equals, hashCode, and toString.
  • Immutability: Records are immutable, promoting thread safety and data integrity.
  • Better Readability: Clearly indicates purpose as data carriers, improving code maintenance and understanding.
  • Automatic Features: Includes auto-generated public constructors and accessors for each component.
  • Practical Uses: Ideal for DTOs, API responses, database views, and event messaging in applications.
  • Polymorphic: Can implement interfaces for polymorphic operations.
  • Local: Can be defined within methods for specific contexts.
  • Limitations: Records cannot extend other classes nor be extended, focusing on simplicity and consistency.
  • Single Responsibility Principle: Records are primarily designed to be data carriers and should not be overloaded with behavior that goes beyond simple data encapsulation.