How Java 17 records work

Java has always been criticized for being too verbose. While that criticism is largely unfounded, language architects and community enthusiasts have always strived to make the language simpler and more concise.

Simple code is easier to maintain. Concise code is less likely to contain bugs.

To make the Java language simpler and concise, a variety of features have been added in recent years, including the following:

However, the single most significant language enhancement that will generate clean and concise code is the Java 17 record type. If you're not using Java records in your code, you should be. This article explains how to use Java 17 records in your code.

How to use Java 17 records

Java records are easy to create. Simply follow these basic rules:

  1. Declare the Record class, which includes a state description.
  2. Do not extend any class other than the Record class.
  3. Remember that your Java records are implicitly final.
  4. There are no setters as all data is immutable.
  5. Instance variables are not allowed.

The declaration of a Java record is incredibly simple. Here's a preview of the Location record we will develop later on in this article:

record Location(double latitude, double longitude) { }

Java records example

To demonstrate how a Java 17 record can help you create cleaner and simpler code, let's first start off with a standard, simple Java class that can identify a person's location, find a center-point when multiple locations are provided, or even find clusters within a data set.

A prototypical implementation would look like the following:

class LocationTracker {
   public Location getCurrentLocation() {
       // Simulate getting location from a GPS sensor
       return new Location(43.6532, -79.3832); // Example for Toronto
   }

   // Finds the center point of the locations
   public Location findCenterPoint(List<Location> locations) {
       double sumX = 0, sumY = 0;
       for (Location loc : locations) {
           sumX += loc.getLatitude();
           sumY += loc.getLongitude();
       }
       return new Location(sumX / locations.size(), sumY / locations.size());
   }

   // Placeholder for more complex methods
   public static List<Location> findClusters(List<Location> locations) {
       // Implement a clustering algorithm (e.g., K-Means, DBSCAN)

       return null;
   }

   public static Location findClosestLocation(List<Location> locations, Location target) {
       // Implement logic to compare distances and find the closest
       return null;
   }
}

Classes vs. records

One common aspect across all these methods is the need to create a Location object.

The Location class must be immutable, because once the data is recorded we don't want to change any of it. To achieve this, we implement the Location class, which consists of around 29 lines of code:

class Location {
   private final double latitude;
   private final double longitude;

   public Location(double latitude, double longitude) {
       this.latitude = latitude;
       this.longitude = longitude;
   }

   public double getLatitude() {
       return latitude;
   }

   public double getLongitude() {
       return longitude;
   }
  
   @Override
   public boolean equals(Object o) {
       if (this == o) return true;
       if (o == null || getClass() != o.getClass()) return false;
       Location location = (Location) o;
       return Double.compare(latitude, location.latitude) == 0 && Double.compare(longitude, location.longitude) == 0;
   }
  
   @Override
   public int hashCode() {
       return Objects.hash(latitude, longitude);
   }
}

In this class, we're only interested in holding two values: latitude and longitude. The rest of the code is simply boilerplate, such as getters, enforcing immutability (using final) and implementing equals(Object o) and hashCode(). While our IDEs can generate this boilerplate on the fly, we still must include it in our source code.

This boilerplate code rarely ever changes, so why include it alongside the core logic? Is there no alternative?

This is where Java records come into play.

What is a Java 17 record?

The Java record type accomplishes the same functionality but does not require the developer to carry the boilerplate. The declaration is simple, as shown below:

record Location(double latitude, double longitude) { }

The above can accomplish exactly what is needed, but the declaration is now just one line. How amazing is that?

Now if we need additional methods in the class, we can simply add them:

record Location(double latitude, double longitude) {

   public double distanceTo(Location other) {
       return 0.0;
   }

   // Outlines for other methods
   public Location offsetBy(double deltaLatitude, double deltaLongitude) {
       return new Location(latitude + deltaLatitude, longitude + deltaLongitude);
   }
}

Here we only add what is required by hand, and nothing ceremonial.

Rules for Record classes

Now that we understand the benefits of using records, let's learn some rules about what we can and cannot do with them.

1. No 'extends' clause, implicitly extends java.lang.Record

Records implicitly extend the java.lang.Record class, so you cannot extend another class or record explicitly.

public record Person(String name, int age) { } 

// This will NOT compile:
public record SpecialPerson extends Person(String name, int age) { }

The example shows that attempting to create a SpecialPerson record that extends Person will not compile.

2. Implicitly final, cannot be abstract

Records are implicitly final, meaning they cannot be extended or declared as abstract.

// These won't compile:
abstract record Payment(String id) { } 
final record LimitedPerson extends Person(String name, int age) { }

The examples demonstrate that declaring an abstract record (Payment) or trying to extend a normal class (LimitedPerson) with a record will not compile.

3. Final fields and Java records

The component fields of a record are implicitly final, which means their values cannot be changed after the record instance is created.

public record Transaction(String id, double amount) {
    // This won't compile - attempting to change a final field:
    public void changeAmount(double newAmount) {
        amount = newAmount; 
    } 
}

The example shows that attempting to change the amount field in the Transaction record's changeAmount() method will not compile.

4. No instance fields or initializers

Records cannot have instance fields or instance initializers beyond the component fields defined in the record header.

public record Point(int x, int y) {
    private double distanceFromOrigin; // Won't compile

    { 
        // Instance initializer won't compile 
        distanceFromOrigin = calculateDistance(); 
    } 
}

The example Point record illustrates that declaring an instance field (distanceFromOrigin) or an instance initializer block will not compile.

5. Explicit members must match derived ones

If you declare explicit members (methods or fields) in a record, their signatures must match the derived components from the record header.

public record Coordinates(double latitude, double longitude) { 
    // This works, matches the auto-generated accessor
    public double latitude() { return latitude; } 

    // This won't compile - type mismatch
    public String latitude() { return Double.toString(latitude); } 
}

In the Coordinates record example, the explicit latitude() method with a double return type is valid, but one with a String return type will not compile due to a type mismatch.

6. A Record class cannot declare native methods

Record classes cannot declare native methods. Native methods depend on an external state, which goes against the immutable nature of records.

public record Stock(String symbol, double price) {
     // Won't compile - native methods not allowed
     public native void updatePrice(); 
}

The Stock record example shows that declaring a native updatePrice() method will not compile.

7. Record classes behave (mostly) like normal classes

Records can have static members and nested classes and can implement interfaces similar to normal classes.

// Top-level or nested:
public record Transaction(String id, double amount) {
    // Static members:
    private static final Logger LOGGER = LoggerFactory.getLogger(Transaction.class);

    // Nested class:
    public static record Status(String code, String description) { } 
}

// Implementing Interfaces:
public record Book(String title, String author) implements Comparable<Book> {
    @Override
    public int compareTo(Book other) {
        // ... Logic for comparison
    }
}

The examples illustrate declaring a static Logger, a nested Status record and implementing the Comparable interface in the Book record.

8. Java records and annotations

A record and the components in its header might be decorated with annotations, such as the following:

public record Transaction(
    @NotNull String id, 
    @Positive double amount) { 
    // ...   
}

The Transaction record example shows annotating the id and amount fields with @NotNull and @Positive annotations, respectively.

9. Record serialization and deserialization

Record instances can be serialized and deserialized, but the process cannot be customized using methods such as writeObject(), readObject(), etc.

public record Person(String name, int age) implements Serializable {
}

public static void main(String[] args) {
   Person rushda = new Person("Rushda", 1);

   // Serialize 'person' object
   serializeToFile("person.ser", rushda);

   // Deserialize into a new 'Person' instance 	
   Person deserializedPerson = deserializeFromFile("person.ser");
   System.out.println(rushda.equals(deserializedPerson)); //should print true
}

private static void serializeToFile(String filename, Person person) {
   try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(filename))) {
       out.writeObject(person);
   } catch (IOException e) {
       throw new RuntimeException("Serialization failed: " + e.getMessage(), e);
   }
}

private static Person deserializeFromFile(String filename) {
   try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(filename))) {
       return (Person) in.readObject();
   } catch (IOException | ClassNotFoundException e) {
       throw new RuntimeException("Deserialization failed: " + e.getMessage(), e);
   }
}

The example demonstrates creating a Person record instance, serializing it and deserializing it into a new Person instance.

Give Java 17 records a try

Java records provide a concise way to define immutable data classes with less boilerplate code. They simplify the codebase, enabling developers to focus on the core functionality while adhering to best practices such as immutability.

For expert Java developers, embracing records can lead to cleaner, more maintainable codebases, ultimately improving productivity and the quality of applications.

A N M Bazlur Rahman is a Java Champion and staff software developer at DNAstack. He is also founder and moderator of the Java User Group in Bangladesh.

View All Videos