Contents

Using Java Records In Real Codebase

In recent years, Java has introduced an impressive number of features. The development of Java has historically been relatively slow; it took about 2.5 years to go from Java 7 to Java 8, then 3.5 years to reach Java 9. But since Java 11, there has been a new Java release every 6 months, and these days LTS1 versions are released every two years.

Some features attract a lot of eyeballs. One feature that deserves more attention is the addition of record classes in Java 16. Record classes, or records for short, are classes which are transparent holders for shallowly immutable data2. Records can be seen as a regular Java class where all fields are final and accessible via getters, and where the equals, hashCode, and toString methods have been properly implemented.

Informally, a record is a radical class; a class that is only meant to be used as a value carrier. Records represent real “things” such as cars, users, and accounts; not logic containers such as controllers, services, or configurations.

The reason to get excited about records two years after their release is that they are now well integrated into the ecosystem but still underused.

Record syntax

Record syntax is extremely short. Here is an example of a record representing a point on a Cartesian coordinate system.

record Point(int x, int y) { }

Below is an equivalent class to the above record.

final class Point {
    private final int x;
    private final int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    int x() { return x; }
    int y() { return y; }

    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
        Point other = (Point) o;
        return other.x == x && other.y == y;
    }

    public int hashCode() {
        return Objects.hash(x, y);
    }

    public String toString() {
        return String.format("Point[x=%d, y=%d]", x, y);
    }
}

Visibly here, a record can save about 15 lines of code. It automates the implementation of equals and hashCode function which are error-prone.

Technicalities

All records are final and, as enums, they cannot extend another class, but they can implement interfaces and have methods of their own.

interface ComparableInSize<T>{
    int compareTo(T o);
}

record Point(int x, int y) implements ComparableInSize<Point> {

    public double distanceFromOrigin(){
        return Math.sqrt(x*x + y*y);
    }
    @Override
    public int compareTo(Point o) {
        return Double.compare(distanceFromOrigin(), o.distanceFromOrigin());
    }
}

Example of use - Typed ids

Records are great for specifying the data that go through a web server. A good utilization of records is to build typed IDs. To lower the probability of an error, it is best to avoid using generic types for IDs such as int or String. It is better to use very specific types such as UserId or StoreId. Doing so reduces the chances of swapping the IDs when calling a function and makes the code much easier to read.

Before records, dedicated ID classes could be considered overkill by some, but since records are available, no IDs should be left untyped. See how easy it is to implement proper ID classes

record UserId(int id){}

record AccountId(int id){}

Below is an example of an API implemented in two different ways. The first one uses untyped IDs, the second one typed IDs. Each seasoned programmer would prefer the latter.

void addAmountToAccount(int userId, int accountId, int amount);
void addAmountToAccount(UserId userId, AccountId accountId, int amount);

3rd party Integrations

Spring Boot

Records can be used transparently in Spring Boot. They are a perfect fit for controllers’ data transfer objects (DTOs).

@RestController
@RequestMapping("/")
public class MainController {

    record CreateUserRequest(String name, int age){ }

    @RequestMapping("/create-person")
    @PostMapping
    public String createPerson(@RequestBody CreateUserRequest req){
        return String.format("Create person %s (age %s)", req.name(), req.age());
    }

}

Theoretically, records can be used for other Spring components such as controllers and services. As Spring components do not hold values but logic, practically it would not make much sense to records for them.

Jackson Integration

The underlying reason why we can easily use records in Spring boot controllers’ DTOs is because Jackson works out-of-the-box with records. Jackson is a very popular serialization library in Java that is used by Spring Boot to interact with JSON.

Serialization and deserialization of records work as expected with Jackson. Below is a test demonstrating the functionality.

class JacksonTest {

    record CreateUserRequest(String name, int age) {
    }

    @Test
    void testJsonSerialization() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        CreateUserRequest req = new CreateUserRequest("didier", 56);
        String serializedRequest = objectMapper.writeValueAsString(req);
        assertEquals("{\"name\":\"didier\",\"age\":56}", serializedRequest);

        CreateUserRequest ret = objectMapper.readValue(serializedRequest, 
                                                       CreateUserRequest.class);
        assertEquals(req, ret);
    }

}

Gson Integration

Gson is a JSON serializer maintained by google. Once more, records are well integrated with gson.


class GsonTest {

    record CreateUserRequest(String name, int age) {
    }

    @Test
    void testJsonSerialization() {
        Gson gson = new Gson();
        CreateUserRequest req = new CreateUserRequest("didier", 56);
        String serializedRequest = gson.toJson(req);
        assertEquals("{\"name\":\"didier\",\"age\":56}", serializedRequest);

        CreateUserRequest ret = gson.fromJson(serializedRequest,
                                              CreateUserRequest.class);
        assertEquals(req, ret);
    }

}

Lombok

Lombok is a formidable library that allows code generation via annotations. Lombok’s support for record is not perfect yet3 4 5 6 but still pretty decent. Obviously, the need to generate code is not as high on record as in regular classes. Implementing getters, equal, and hashcode methods is a common Lombok use case that becomes unnecessary with records because they are already implemented.

Importantly to many, the @NonNull is available to mark record field as not nullable. If a null value is passed to a field annotated with @NonNull, a NullPointerException will be thrown.

Testing library

No code is ready for production before it is tested. Records do not require different testing methodologies than Java beans.

Hamcrest has an open ticket or two about records 7 8. Sincerely, these are minor issues and should not cause any problems to anyone.

Assertj does not have any open record-related bugs. In fact, they plan to improve record support in version 49

Conclusion

As more code bases are moving to Java 17 and Java 21, it would be nice to see more usage of record classes. Perhaps some people will discover unexpected ways to use records.

Library Version Used

This blog post has used the below library versions.

LibraryVersion
org.projectlombok:lombok1.18.28
org.hamcrest:hamcrest-all1.3
org.assertj:assertj-core3.24.2
com.google.code.gson:gson2.10.1
org.springframework.boot:spring-boot-starter-web3.1.3
graalvm-ce-1717.0.7